原文地址 juejin.cn
本次 WWDC 中,苹果发布了 dyld3.0。Dyld(动态连接器)的更新对 app 的启动速度、安全性等方面有着重大影响。本文介绍了 dyld 的历史和新的 dyld 3.0 所做的优化,以及适配 dyld 3.0 时需要注意的问题。
本篇相关文档和 session 有:
一、如何优化 app 的启动时长
在本次 session 开始之前,我们需要了解这样几个术语:
- 启动时间:本次讨论的启动时间,指的是 main() 方法调用之前的时间。
- 启动闭包(launch closure):这是一个新引入的概念,指的是 app 在启动期间所需要的所有信息。比如这个 app 使用了哪些动态链接库,其中各个符号的偏移量,代码签名在哪里等等。
如何减少启动时间,最重要的还是尽可能的少做事。比如:尽可能的嵌入更少的动态链接库;尽可能少引入文件、少定义方法;尽可能少执行初始化程序。
另外,苹果还建议更多使用 Swift。Swift 在设计上能避免很多 C、C++ 和 OC 的陷阱;Swift 没有初始化过程;Swift 也不允许不对齐的结构体。这些都对启动时间的优化有一定帮助。
在 iOS 11 和 macOS 10.13 中,苹果给 Instruments 增加了一个工具,名叫静态初始化程序跟踪器(Static Initializer Tracing),用于定为 main() 函数调用之前的启动速度瓶颈。 由于这些初始化程序是在 main() 之前调用的,以前我们很难调试它们。而现在,这个工具能提供每一个静态初始化程序的时间,帮助我们找到耗时最长部分。
在 WWDC 2016 中,苹果已经介绍了从 app 启动到 main() 函数执行这之间的工作:app 开始启动后,系统首先加载可执行文件,然后加载动态链接库。动态链接库的加载速度直接影响着 app 的启动速度,而 dyld 就是专门用来加载动态链接库的库。通过回顾 dyld 的发展过程,我们可以看到苹果在 app 启动上做的一系列优化。
二、Dyld 的历史
Dyld 1.0(1996-2004)
Dyld 1.0 装载于 NeXTStep 3.3,在 dyld 出现之前,NeXT 使用静态的二进制文件。
相比于静态的二进制文件,引入动态链接库可以实现代码共用,节约内存和磁盘空间;各个动态链接库的更新变得比较容易,易于库的维护;动态链接库在构建时不需要合并到可执行文件中去,能大大缩减可执行文件的体积。
但是值得注意的是,在 dyld 1.0 出现时,POSIX dlopen() 还没有被标准化。现在 dlopen() 确实出现在了一部分 Unix 操作系统上,但这是得益于人们在后来适配了专门的扩展程序;而 NeXTStep 开发了与 Unix 不同的扩展程序,所以在 MacOS 10 上,人们必须使用第三方的封装函数来适配标准的 Unix 软件。而问题是,它们的语义并不完全一致,所以会出现一些怪异的边界案例,并且效率很慢。
并且,dyld 1.0 出现的时候,很多系统还没有使用大型 C++ 动态库,所以 dyld 1.0 在面对一些大型 C++ 库时,动态链接非常慢。
在 macOS 10.0,苹果开启了一个新的功能:预绑定(prebinding),用于找到系统中每个 dylib 的固定的地址,动态连接器会尝试从这些地址中加载,如果加载成功,就会编辑这些二进制,等到下次他们被放到同样的地址上时,就不需要做任何工作了。这样能大幅优化启动速度,但这意味着二进制文件在每次启动时都被修改,在安全性和其他方面都有隐患。
Dyld 2.0(2004-2007)
Dyld 2.0 出现在 macOS Tiger 上,是 dyld 的一次全面重写。它拥有正确的 C++ 语法支持,能对 C++ 库进行高效支持。
Dyld 2.0 完全支持了 dlopen() / dlsym() 语法,也就是说它抛弃了旧时期的接口。
Dyld 2.0 是为了效率而设计的,所以在健全性检验上有一些限制。因此它也有安全性问题,所以苹果不断的改进它以适应今天的平台。
另外,由于 dyld 2.0 在性能有了显著提升,所以 dyld 1.0 中的预绑定被抛弃了。
Dyld 2.0 发布至今,苹果对它进行了不断的优化和升级,即迭代出了 dyld 2.x ,其中的优化包括:
- 不断增加了平台和架构:dyld2 起源于 PowerPC,后来增加了 x86,x86_64,arm,arm64 等架构的支持,并且增加了 iOS,tvOS,watchOS 平台;
- 从多个角度增加了安全性,增加了代码签名支持,地址空间配置随机加载(Address space layout randomization)和边界检查;
- 提升了性能,因此预绑定被完全废弃了,取而代之的是 shared cache
Shared cache 是 iOS 3.1 和 macOS Snow Leopard 引入的,它完全取代了预绑定。这是一个包含了大部分系统动态库的文件,正是由于这些都被合入到了一个文件,我们可以做一些优化。比如重新组织 TEXT 段、 DATA 段和整个符号表来缩减大小。它能打包二进制段,因此能节约内存空间。实际上它是一种动态库的预链接。它预先构建了 dyld 和 ObjC 需要的数据结构,这节约了内存和时间。
三、Dyld 3.0
今年,苹果发布了 dyld 3.0,dyld 3 是一个全新的动态链接器,它即将成为新的 macOS 和 iOS 上大部分系统 app 的默认动态链接器,在未来也会被用于第三方 app,来完全取代 dyld 2。
为什么苹果需要引入 dyld 3?核心的理由有三点:性能、安全性和可测试性。什么是理论上启动一个 app 所要完成的最少的任务呢?我们能有更激进的安全性检查么?我们能让 dyld 更便于测试么?这些是苹果一直在思考的问题,也是 dyld 的改革方向。 那么应该如何做到以上这些呢?
- 尽可能将复杂操作放到进程以外:如果 dyld 中的大部分只是一个常规的 daemon 进程,用标准化的测试工具就可以测试。
- 让进程中的需要执行的动态链接操作尽可能少:这样可以减少 app 中能被攻击的部分,并且能提升 app 的启动速度。
在介绍 dyld 3 之前,首先我们来回顾一下 dyld 2 是如何启动一个 app 的:
- 解析 mach-o 文件,找到其依赖的库,并且递归的找到所有依赖的库,形成一张动态库的依赖图。iOS 上的大部分 app 都依赖 300 到 600 个动态链接库,所以这个步骤包含了较大的工作量。
- 匹配 mach-o 文件到自身的地址空间
- 进行符号查找:比如 app 中调用了 printf 方法,就需要去系统库中查找到 printf 的地址,然后将地址拷贝到 app 中的函数指针中
- 绑定和变基:由于 app 需要让地址空间配置随机加载,所以所有的指针都需要加上一个基地址
- 运行初始化程序,之后运行 main() 函数
那么这些步骤在性能、安全性和可测试性上应该如何被优化呢?苹果提出了这样两点思路:
- 识别安全性敏感的组件:解析 mach-o 文件并寻找依赖是安全性敏感的,因为恶意篡改的 mach-o 头部可以进行某些攻击,如果一个 app 使用了 @rpath,那么恶意修改路径或者将一些库插入到特定的地方,攻击者就可以毁坏 app。所以这部分工作需要被搬到进程外来完成,比如搬到一个 daemon 进程中。
- 识别可以被缓存的部分:符号查找就是其中一个,因为在一个特定的库中,除非软件更新或者这个库被改变,不然每个符号都应该有固定的偏移量。
以上两点思路也是 dyld 3.0 的优化思路。在 dyld 3.0 中,mach-o 头部解析和符号查找工作完成后,这些执行结果会被作为 “启动闭包(launch closure)” 写入硬盘。
所以我们可以认为 dyld 3.0 是 3 个组件的结合:
- 一个进程外的 MachO 解析器 / 编译器:它处理了所有可能影响启动速度的 search path,@rpaths 和环境变量;它解析 mach-o 二进制文件,并且完成了所有符号查找的工作;最后它将这些工作的结果创建成了启动闭包。这是一个普通的 daemon 进程,可以使用通常的测试架构。
- 一个进程内的引擎,来运行启动闭包:它所做的一切是验证启动闭包,将动态链接库映射出去,然后跳转到 main() 函数中。它不需要解析 mach-o 头部,也不需要做符号查找。
- 一个启动闭包缓存服务:系统 app 的启动闭包被构建在一个 shared cache 中,我们甚至不需要打开一个单独的文件;对于第三方 app,我们会在 app 安装或者系统升级的时候构建这个启动闭包。在 iOS,tvOS,watchOS 中,一切都是在 app 启动之前做完的。在 macOS 上,由于有 sideload app,进程内引擎会在首次启动时启动一个 daemon,之后就可以使用启动闭包了。总之大部分情景下,这些工作都在 app 启动之前完成了。
大部分的启动场景都不需要调用这个进程外的 mach-o 解析器。而启动闭包又比 MachO 简单很多,因为它是一个内存映射文件,解析和验证都非常简单,并且经过了良好的性能优化。所以 dyld 3.0 的引入,能让 app 的启动速度得到明显提升。
从今年开始,系统 app 就将使用 dyld 3.0 了,未来第三方 app 也将会使用 dyld 3,最终 dyld 3 将全面取代 dyld 2。
四、潜在问题和适配方案
Dyld 3 完全兼容 dyld 2.x,但在一些特殊场景下也可能会有一些潜在问题。在适配时我们需要注意以下几点:
部分接口的性能
由于 dyld 3 需要完全兼容 dyld 2.x,所以有些现存接口会进入降级模式,这些接口的执行效率将会特别慢。所以在使用时,我们需要注意避免这样的接口。另外,有些针对于 2.x 的优化将不再起作用。
更严格的链接语法
在一些边界条件下,某一些操作的行为在今天看来其实是不正确的,苹果收集了这些案例并且在 dyld 3 中对这些操作进行了改进。这就可能导致 dyld 3 中某些行为的结果和 dyld 2 中不符。苹果对旧的二进制做了一些兼容,但是链接器将会禁止新的二进制使用这些方案,所以可能会出现链接错误。
需要修正不对齐指针
如果有一个全局的结构体,它指向一个函数或者另一个全局结构体,那么为了更好的运行性能这个指针必须和系统字长对齐。如果出于某些特殊原因,第三方程序员必须使用 attributes 来强行指定对齐方式,那么系统会在 app 启动时矫正这些不对齐的指针,但矫正是一个复杂的工作。所以静态链接器会产生一个 warning,建议程序员自行修正不对齐的指针。不过 Swift 代码不存在这个问题。
需要解决符号缺失问题
由于符号解析的开销很大,dyld 2 默认采取的是懒惰的符号解析(lazy symbol resolution)策略。也就是说在二进制文件中,printf 并不指向真正的 printf 函数的地址,首次访问 printf 的时候,其实是访问了 dyld 中的一个方法,这个方法会返回 printf 的函数地址。从第二次访问 printf 开始,app 才会直接访问 printf 函数。
但是引入了 dyld 3.0 之后,在 app 启动之前,符号解析的结果已经在启动闭包内了,所以 “懒惰的符号解析” 策略也不再被需要。这时,如果有符号缺失的情况,app 的行为也将和 dyld 2.0 时期不同:dyld 2 中,首次调用缺失符号时 app 会 crash;而 dyld 3.0 中,缺失符号会导致 app 一启动就会 crash。
为了防止切换到 dyld 3.0 时缺失符号造成 app 的启动 crash,苹果提供了一个链接器标志-bind_at_link
,在 dyld 2.x 的环境下不做懒惰符号解析,来帮助第三方程序员及时发现符号缺失的问题。当然,由于-bind_at_link
会降低 app 的性能,所以这个链接器标志应该只在 DEBUG 模式下使用。
抛弃 dlopen() / dlsym() / dladdr()
尽可能的不要使用 dlopen() / dlsym() / dladdr() 方法,这些方法在 dyld 3 环境下,性能开销变得更大。
抛弃 dlclose()
dlclose() 的名字和它实际的功能是不相符的,在苹果的平台上,它代表的意思其实是 dlrelease,它可能并不会真的关闭 dylib。并且,苹果的平台有一些防止 dylib 卸载的机制,比如如果 dylib 中有 OC 类或者 Swift 类,dylib 就不会卸载;或者如果 dylib 中有 C 的 thread 和 C++ 的 thread_local 变量,dylib 也不会卸载。所以在 macOS 之外的平台,dlclose() 都应该被认为是一个无效的方法。
抛弃 all_image_infos
all_image_infos 接口起源于 dyld 1,如果我们有 300、400 个动态链接库,这个方法会浪费大量内存。所以苹果预计在未来的版本中废弃这个方法,同时苹果将会提供相应的替代接口。
适配方案的最佳实践
总结一下上述的适配方案,对于第三方程序员来说,我们在编码时应该做到以下几点:
- 在 DEBUG 模式下,将 -bind_at_load 被添加链接标志符中
- 参考静态链接器的警告,修复所有不对齐的指针
- 抛弃依赖 dlclose() 方法
- 如果必须使用,那么请让苹果知道你为什么需要使用 dlopen()、dlsym()、dladdr()、all_image_infos。
Dyld 3 的出现,改变了动态链接的流程,相信 dyld 3 能对 app 的启动速度带来明显优化。但是目前只有系统 app 可以使用 dyld 3,第三方 app 开放使用 dyld 3 预计还得等待一些时日。
虽然 dyld 3 与 dyld 2 完全兼容,但在一些极端用例下,从 dyld 2 切换到 dyld 3 可能还是会有一些问题,所以各个第三方 app 的开发者也应该对适配有所留意,按照苹果提供的建议进行检查。